Español

Explore los fundamentos de los Árboles Binarios de Búsqueda (BST) y aprenda a implementarlos de manera eficiente en JavaScript. Esta guía cubre la estructura, operaciones y ejemplos prácticos de los BST para desarrolladores de todo el mundo.

Árboles Binarios de Búsqueda: Guía Completa de Implementación en JavaScript

Los Árboles Binarios de Búsqueda (BST, por sus siglas en inglés) son una estructura de datos fundamental en las ciencias de la computación, ampliamente utilizados para la búsqueda, ordenación y recuperación eficiente de datos. Su estructura jerárquica permite una complejidad de tiempo logarítmica en muchas operaciones, lo que los convierte en una herramienta poderosa para gestionar grandes conjuntos de datos. Esta guía proporciona una visión completa de los BST y demuestra su implementación en JavaScript, dirigida a desarrolladores de todo el mundo.

Entendiendo los Árboles Binarios de Búsqueda

¿Qué es un Árbol Binario de Búsqueda?

Un Árbol Binario de Búsqueda es una estructura de datos basada en árboles donde cada nodo tiene como máximo dos hijos, conocidos como el hijo izquierdo y el hijo derecho. La propiedad clave de un BST es que para cualquier nodo dado:

Esta propiedad asegura que los elementos en un BST estén siempre ordenados, permitiendo una búsqueda y recuperación eficientes.

Conceptos Clave

Implementando un Árbol Binario de Búsqueda en JavaScript

Definiendo la Clase Node

Primero, definimos una clase `Node` para representar cada nodo en el BST. Cada nodo contendrá una `key` para almacenar el dato y punteros `left` y `right` a sus hijos.


class Node {
  constructor(key) {
    this.key = key;
    this.left = null;
    this.right = null;
  }
}

Definiendo la Clase BinarySearchTree

A continuación, definimos la clase `BinarySearchTree`. Esta clase contendrá el nodo raíz y los métodos para insertar, buscar, eliminar y recorrer el árbol.


class BinarySearchTree {
  constructor() {
    this.root = null;
  }

  // Los métodos se agregarán aquí
}

Inserción

El método `insert` agrega un nuevo nodo con la clave dada al BST. El proceso de inserción mantiene la propiedad del BST al colocar el nuevo nodo en la posición apropiada en relación con los nodos existentes.


insert(key) {
  const newNode = new Node(key);

  if (this.root === null) {
    this.root = newNode;
  } else {
    this.insertNode(this.root, newNode);
  }
}

insertNode(node, newNode) {
  if (newNode.key < node.key) {
    if (node.left === null) {
      node.left = newNode;
    } else {
      this.insertNode(node.left, newNode);
    }
  } else {
    if (node.right === null) {
      node.right = newNode;
    } else {
      this.insertNode(node.right, newNode);
    }
  }
}

Ejemplo: Insertando valores en el BST


const bst = new BinarySearchTree();
bst.insert(11);
bst.insert(7);
bst.insert(15);
bst.insert(5);
bst.insert(3);
bst.insert(9);
bst.insert(8);
bst.insert(10);
bst.insert(13);
bst.insert(12);
bst.insert(14);
bst.insert(20);
bst.insert(18);
bst.insert(25);

Búsqueda

El método `search` comprueba si existe un nodo con la clave dada en el BST. Recorre el árbol, comparando la clave con la clave del nodo actual y moviéndose al subárbol izquierdo o derecho según corresponda.


search(key) {
  return this.searchNode(this.root, key);
}

searchNode(node, key) {
  if (node === null) {
    return false;
  }

  if (key < node.key) {
    return this.searchNode(node.left, key);
  } else if (key > node.key) {
    return this.searchNode(node.right, key);
  } else {
    return true;
  }
}

Ejemplo: Buscando un valor en el BST


console.log(bst.search(9));  // Salida: true
console.log(bst.search(2));  // Salida: false

Eliminación

El método `remove` elimina un nodo con la clave dada del BST. Esta es la operación más compleja, ya que necesita mantener la propiedad del BST mientras elimina el nodo. Hay tres casos a considerar:


remove(key) {
  this.root = this.removeNode(this.root, key);
}

removeNode(node, key) {
  if (node === null) {
    return null;
  }

  if (key < node.key) {
    node.left = this.removeNode(node.left, key);
    return node;
  } else if (key > node.key) {
    node.right = this.removeNode(node.right, key);
    return node;
  } else {
    // la clave es igual a node.key

    // caso 1 - un nodo hoja
    if (node.left === null && node.right === null) {
      node = null;
      return node;
    }

    // caso 2 - el nodo tiene solo 1 hijo
    if (node.left === null) {
      node = node.right;
      return node;
    } else if (node.right === null) {
      node = node.left;
      return node;
    }

    // caso 3 - el nodo tiene 2 hijos
    const aux = this.findMinNode(node.right);
    node.key = aux.key;
    node.right = this.removeNode(node.right, aux.key);
    return node;
  }
}

findMinNode(node) {
  let current = node;
  while (current != null && current.left != null) {
    current = current.left;
  }
  return current;
}

Ejemplo: Eliminando un valor del BST


bst.remove(7);
console.log(bst.search(7)); // Salida: false

Recorrido del Árbol

El recorrido del árbol implica visitar cada nodo del árbol en un orden específico. Hay varios métodos comunes de recorrido:


inOrderTraverse(callback) {
  this.inOrderTraverseNode(this.root, callback);
}

inOrderTraverseNode(node, callback) {
  if (node !== null) {
    this.inOrderTraverseNode(node.left, callback);
    callback(node.key);
    this.inOrderTraverseNode(node.right, callback);
  }
}

preOrderTraverse(callback) {
  this.preOrderTraverseNode(this.root, callback);
}

preOrderTraverseNode(node, callback) {
  if (node !== null) {
    callback(node.key);
    this.preOrderTraverseNode(node.left, callback);
    this.preOrderTraverseNode(node.right, callback);
  }
}

postOrderTraverse(callback) {
  this.postOrderTraverseNode(this.root, callback);
}

postOrderTraverseNode(node, callback) {
  if (node !== null) {
    this.postOrderTraverseNode(node.left, callback);
    this.postOrderTraverseNode(node.right, callback);
    callback(node.key);
  }
}

Ejemplo: Recorriendo el BST


const printNode = (value) => console.log(value);

bst.inOrderTraverse(printNode);   // Salida: 3 5 8 9 10 11 12 13 14 15 18 20 25
bst.preOrderTraverse(printNode);  // Salida: 11 5 3 9 8 10 15 13 12 14 20 18 25
bst.postOrderTraverse(printNode); // Salida: 3 8 10 9 12 14 13 18 25 20 15 11

Valores Mínimo y Máximo

Encontrar los valores mínimo y máximo en un BST es sencillo, gracias a su naturaleza ordenada.


min() {
  return this.minNode(this.root);
}

minNode(node) {
  let current = node;
  while (current !== null && current.left !== null) {
    current = current.left;
  }
  return current;
}

max() {
  return this.maxNode(this.root);
}

maxNode(node) {
  let current = node;
  while (current !== null && current.right !== null) {
    current = current.right;
  }
  return current;
}

Ejemplo: Encontrando los valores mínimo y máximo


console.log(bst.min().key); // Salida: 3
console.log(bst.max().key); // Salida: 25

Aplicaciones Prácticas de los Árboles Binarios de Búsqueda

Los Árboles Binarios de Búsqueda se utilizan en una variedad de aplicaciones, incluyendo:

Consideraciones de Rendimiento

El rendimiento de un BST depende de su estructura. En el mejor de los casos, un BST balanceado permite una complejidad de tiempo logarítmica para las operaciones de inserción, búsqueda y eliminación. Sin embargo, en el peor de los casos (por ejemplo, un árbol sesgado), la complejidad de tiempo puede degradarse a tiempo lineal.

Árboles Balanceados vs. No Balanceados

Un BST balanceado es aquel en el que la altura de los subárboles izquierdo y derecho de cada nodo difiere como máximo en uno. Los algoritmos de autobalanceo, como los árboles AVL y los árboles Rojo-Negro, aseguran que el árbol permanezca balanceado, proporcionando un rendimiento constante. Diferentes regiones pueden requerir diferentes niveles de optimización basados en la carga del servidor; el balanceo ayuda a mantener el rendimiento bajo un alto uso global.

Complejidad Temporal

Conceptos Avanzados de BST

Árboles Autobalanceados

Los árboles autobalanceados son BST que ajustan automáticamente su estructura para mantener el balance. Esto asegura que la altura del árbol se mantenga logarítmica, proporcionando un rendimiento constante para todas las operaciones. Los árboles autobalanceados comunes incluyen los árboles AVL y los árboles Rojo-Negro.

Árboles AVL

Los árboles AVL mantienen el balance asegurando que la diferencia de altura entre los subárboles izquierdo y derecho de cualquier nodo sea como máximo uno. Cuando este balance se interrumpe, se realizan rotaciones para restaurarlo.

Árboles Rojo-Negro

Los árboles Rojo-Negro utilizan propiedades de color (rojo o negro) para mantener el balance. Son más complejos que los árboles AVL pero ofrecen un mejor rendimiento en ciertos escenarios.

Ejemplo de Código JavaScript: Implementación Completa del Árbol Binario de Búsqueda


class Node {
  constructor(key) {
    this.key = key;
    this.left = null;
    this.right = null;
  }
}

class BinarySearchTree {
  constructor() {
    this.root = null;
  }

  insert(key) {
    const newNode = new Node(key);

    if (this.root === null) {
      this.root = newNode;
    } else {
      this.insertNode(this.root, newNode);
    }
  }

  insertNode(node, newNode) {
    if (newNode.key < node.key) {
      if (node.left === null) {
        node.left = newNode;
      } else {
        this.insertNode(node.left, newNode);
      }
    } else {
      if (node.right === null) {
        node.right = newNode;
      } else {
        this.insertNode(node.right, newNode);
      }
    }
  }

  search(key) {
    return this.searchNode(this.root, key);
  }

  searchNode(node, key) {
    if (node === null) {
      return false;
    }

    if (key < node.key) {
      return this.searchNode(node.left, key);
    } else if (key > node.key) {
      return this.searchNode(node.right, key);
    } else {
      return true;
    }
  }

  remove(key) {
    this.root = this.removeNode(this.root, key);
  }

  removeNode(node, key) {
    if (node === null) {
      return null;
    }

    if (key < node.key) {
      node.left = this.removeNode(node.left, key);
      return node;
    } else if (key > node.key) {
      node.right = this.removeNode(node.right, key);
      return node;
    } else {
      // la clave es igual a node.key

      // caso 1 - un nodo hoja
      if (node.left === null && node.right === null) {
        node = null;
        return node;
      }

      // caso 2 - el nodo tiene solo 1 hijo
      if (node.left === null) {
        node = node.right;
        return node;
      } else if (node.right === null) {
        node = node.left;
        return node;
      }

      // caso 3 - el nodo tiene 2 hijos
      const aux = this.findMinNode(node.right);
      node.key = aux.key;
      node.right = this.removeNode(node.right, aux.key);
      return node;
    }
  }

  findMinNode(node) {
    let current = node;
    while (current != null && current.left != null) {
      current = current.left;
    }
    return current;
  }

  min() {
    return this.minNode(this.root);
  }

  minNode(node) {
    let current = node;
    while (current !== null && current.left !== null) {
      current = current.left;
    }
    return current;
  }

  max() {
    return this.maxNode(this.root);
  }

  maxNode(node) {
    let current = node;
    while (current !== null && current.right !== null) {
      current = current.right;
    }
    return current;
  }

  inOrderTraverse(callback) {
    this.inOrderTraverseNode(this.root, callback);
  }

  inOrderTraverseNode(node, callback) {
    if (node !== null) {
      this.inOrderTraverseNode(node.left, callback);
      callback(node.key);
      this.inOrderTraverseNode(node.right, callback);
    }
  }

  preOrderTraverse(callback) {
    this.preOrderTraverseNode(this.root, callback);
  }

  preOrderTraverseNode(node, callback) {
    if (node !== null) {
      callback(node.key);
      this.preOrderTraverseNode(node.left, callback);
      this.preOrderTraverseNode(node.right, callback);
    }
  }

  postOrderTraverse(callback) {
    this.postOrderTraverseNode(this.root, callback);
  }

  postOrderTraverseNode(node, callback) {
    if (node !== null) {
      this.postOrderTraverseNode(node.left, callback);
      this.postOrderTraverseNode(node.right, callback);
      callback(node.key);
    }
  }
}

// Ejemplo de Uso
const bst = new BinarySearchTree();
bst.insert(11);
bst.insert(7);
bst.insert(15);
bst.insert(5);
bst.insert(3);
bst.insert(9);
bst.insert(8);
bst.insert(10);
bst.insert(13);
bst.insert(12);
bst.insert(14);
bst.insert(20);
bst.insert(18);
bst.insert(25);

const printNode = (value) => console.log(value);

console.log("Recorrido in-order:");
bst.inOrderTraverse(printNode);

console.log("Recorrido pre-order:");
bst.preOrderTraverse(printNode);

console.log("Recorrido post-order:");
bst.postOrderTraverse(printNode);

console.log("Valor mínimo:", bst.min().key);
console.log("Valor máximo:", bst.max().key);

console.log("Búsqueda de 9:", bst.search(9));
console.log("Búsqueda de 2:", bst.search(2));

bst.remove(7);
console.log("Búsqueda de 7 después de la eliminación:", bst.search(7));

Conclusión

Los Árboles Binarios de Búsqueda son una estructura de datos potente y versátil con numerosas aplicaciones. Esta guía ha proporcionado una visión completa de los BST, cubriendo su estructura, operaciones e implementación en JavaScript. Al comprender los principios y técnicas discutidos en esta guía, los desarrolladores de todo el mundo pueden utilizar eficazmente los BST para resolver una amplia gama de problemas en el desarrollo de software. Desde la gestión de bases de datos globales hasta la optimización de algoritmos de búsqueda, el conocimiento de los BST es un activo invaluable para cualquier programador.

A medida que continúe su viaje en las ciencias de la computación, explorar conceptos avanzados como los árboles autobalanceados y sus diversas implementaciones mejorará aún más su comprensión y capacidades. Siga practicando y experimentando con diferentes escenarios para dominar el arte de usar los Árboles Binarios de Búsqueda de manera efectiva.